Skip to content

feat(form): add fs-form — useFormSubmit + useValidationErrors#148

Merged
Goosterhof merged 3 commits into
mainfrom
feat/fs-form-package
Jul 9, 2026
Merged

feat(form): add fs-form — useFormSubmit + useValidationErrors#148
Goosterhof merged 3 commits into
mainfrom
feat/fs-form-package

Conversation

@Goosterhof

@Goosterhof Goosterhof commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What

Forges @script-development/fs-form — the extracted, shared version of the form-submit composable pair that BIO and entreezuil independently ran side-by-side (Commander called the forge 2026-07-08).

The one-call entry point is useForm:

const {errors, submitting, handleSubmit} = useForm<Field>(http);

It wires two primitives together so a page composes one call, not two:

  • useValidationErrors(httpService, {keyMapper?}) — a 422-only response-error middleware binding backend field errors into a reactive Partial<Record<field, message>> bag (first message per field), auto-unregisters on unmount.
  • useFormSubmit(validationErrors) — double-submit guard + submitting flag (this is the form's loading state); clears prior errors, swallows a 422 (field errors already surfaced → the populated form is preserved), re-throws everything else.

Both primitives stay exported for the one-half cases (a validation-less confirm action wants the submit guard without the 422 middleware).

Design decisions (settled before the forge)

Decision Resolution
One composable useForm aggregates both; primitives kept exported. Aggregator-over-primitives, not a collapse.
Loading state It's submitting — the per-form in-flight flag, surfaced from the one call. Deliberately NOT fs-loading: its counter is app-global, so an unrelated background request would flip this form's spinner — wrong granularity for per-form state.
Policy Narrow (422-only), matching both source territories. kendo's broad keep-form-on-any-surfaced-error policy stays kendo-specific.
The one divergence BIO camelCased error keys, entreezuil used them raw. Absorbed by an optional keyMapper (default identity).
Principle #8 The middleware body is wrapped with fs-http's guarded() — a throwing keyMapper cannot reject a resolved request or mask the real API error.

Gauntlet (all 8 CI gates green locally)

  • build — dual ESM + CJS, d.cts emitted (no direct axios import — routed through fs-http re-exports)
  • typecheck / lint / format — clean; lint:pkg publint + attw + sideEffects:false side-effect-free assertion pass
  • coverage100% lines/branches/functions/statements (28 tests)
  • mutation92.31% (≥90 break); useForm + useFormSubmit 100%. The 3 residual survivors in validation-errors.ts are equivalent mutants the guarded() backstop creates: a removed null-guard throws, guarded() swallows it, the result collapses to the same empty bag. Killing them would require removing the Principle feat: add @script-development/fs-adapter-store package #8 guard — correct code over a vanity 100%.

⚠️ Publish precondition (not blocking review)

The npm Trusted Publisher grant must be wired on npmjs.org before the first CI publish (org script-development · repo fs-packages · workflow publish.yml · env npm-publish), or the publish 404s. Commander console step — flagging it so the merge doesn't silently strand at no-publish.

Adoption (follow-up, opt-in)

BIO + entreezuil migrate their local composables onto useForm in separate PRs. Global-middleware territories (ublgenie/wijs/emmie) are retrofit targets, not drop-ins.

🤖 Generated with Claude Code

https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq

Extract the form-submit composable pair that BIO and entreezuil independently
ran side-by-side into the armory:

- useValidationErrors(httpService, {keyMapper?}) — registers a 422-only
  response-error middleware binding backend field errors into a reactive bag
  (first message per field), auto-unregisters on unmount. The one axis the two
  source territories diverged on — raw vs camelCased error keys — is absorbed by
  an optional keyMapper (default identity).
- useFormSubmit(validationErrors) — double-submit guard + submitting flag;
  swallows a 422 (errors already surfaced, form preserved) and re-throws else.

The middleware body is wrapped with fs-http's guarded() so a throwing keyMapper
cannot reject a resolved request or mask the real API error — Principle #8
compliant out of the box.

Gauntlet: dual ESM+CJS build, 100% coverage (22 tests), 91.89% mutation
(3 residual survivors are equivalent mutants the guarded() backstop creates —
killing them would mean removing the Principle #8 guard). publint + attw clean;
sideEffects:false side-effect-free assertion passes.

Docs (docs/packages/form.md + sidebar) and the CLAUDE.md package table updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq
@Goosterhof
Goosterhof requested a review from a team as a code owner July 8, 2026 08:05
@Goosterhof Goosterhof added the Agent Review Requested Requesting review of specialized AI review agents. label Jul 8, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 8, 2026

Copy link
Copy Markdown

Deploying fs-packages with  Cloudflare Pages  Cloudflare Pages

Latest commit: 979c54f
Status: ✅  Deploy successful!
Preview URL: https://4932eaa8.fs-packages.pages.dev
Branch Preview URL: https://feat-fs-form-package.fs-packages.pages.dev

View logs

@Goosterhof Goosterhof left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Self-review (COMMENT — own PR)

Devil's-advocate pass over the fs-form forge. 0 blockers · 0 majors · 1 caveat · 1 nit · 1 praise · 2 inline.

The pair is correctly complementary: useValidationErrors' guarded() 422 middleware populates the bag as a side effect (fs-http still rejects the request), and useFormSubmit swallows the 422 rejection — so the populated form survives without either piece double-owning the outcome. Principle #8 is honoured at the one place it matters (guarded() around the keyMapper/parse body). keyMapper-default-identity cleanly absorbs the BIO-camelCase / entreezuil-raw divergence at a single injection point. Types are clean, index.ts re-exports only the public surface, and no direct axios import (routed through fs-http re-exports) keeps the dep graph honest.

Notes (detail inline)

  • 🟡 packages/form/src/validation-errors.ts:52 — per-consumer 422 bag → cross-form bleed when two consumers share one HttpService; wants an explicit docs note.
  • packages/form/src/validation-errors.ts:23 — unchecked messages[0] on a non-array field (defensive-depth only).
  • praise: the honest mutation-score writeup — the 3 survivors are equivalent mutants the guarded() backstop creates; refusing to strip the guard for a vanity 100% is exactly the Principle #8 discipline.

CI note: the check 8-gate job is pending at review time — verdict is COMMENT (self-authored PR; GitHub blocks self-approve regardless). The npm Trusted Publisher publish precondition is correctly flagged as a non-blocking Commander console step.

Automated war-room agent review — posted because this PR carries the Agent Review Requested label.

Comment thread packages/form/src/validation-errors.ts
Comment thread packages/form/src/validation-errors.ts
Wire useValidationErrors + useFormSubmit into a single useForm(httpService,
{keyMapper?}) so a page composes one call, not two — returns {errors,
clearErrors, handleSubmit, submitting}. submitting is the form's loading state
(true while a submit is in flight); no fs-loading coupling (its global counter
is the wrong granularity for per-form state).

The primitives stay exported for the one-half cases (validation-less confirm
action, etc.). Docs lead with useForm; primitives documented as composition.

Gauntlet: 100% coverage (form.ts + form-submit.ts 100% mutation), 92.31%
package mutation, lint:pkg clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq

@Goosterhof Goosterhof left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Self-review R2 (COMMENT — own PR)

Re-review at 174f2c65. Delta since c0a81703 = the useForm one-call aggregator (feat(form): add useForm over the two primitives). Clean and correct: useForm(httpService, options) wires useValidationErrors(options) + useFormSubmit(validation) and returns {errors, clearErrors, handleSubmit, submitting}; UseForm<T> = UseValidationErrors<T> & UseFormSubmit is a collision-free intersection; UseFormOptions threads keyMapper through to the validation half; both primitives stay exported for one-half use, and the docs lead with useForm while keeping the compose-by-hand path. +152 test lines. No new blockers or majors.

The two caveats from my first review carry over unchanged — both still non-blocking, and both now reachable via useForm too since it inherits the primitives verbatim:

  • 🟡 per-consumer 422 bag → cross-form bleed when two consumers share one HttpService (validation-errors.ts:52, thread still open). The docs Cleanup section covers unmount-scoping but not the two-forms-one-service case; a one-line note there closes it.
  • ⚪ unchecked messages[0] on a non-array field (validation-errors.ts:23, thread still open).

Both are author's-call — flagging that they persist, not re-raising as new findings.

CI note: check is re-running on the new commit at review time; verdict is COMMENT regardless (self-authored PR).

Automated war-room agent review — posted because this PR carries the Agent Review Requested label.

Address the two carried self-review findings on PR #148 as docs, both
author's-call / non-blocking:

- Cross-form bleed (🟡): document that one HttpService/error-scope is required
  per form — two forms sharing one service both fill from any 422.
- messages[0] cast (⚪): skip the runtime guard by design; instead document that
  fs-form targets Laravel's 422 contract (errors: Record<string,string[]>) and
  does not defensively handle other backend shapes. Revisit if a non-Laravel
  consumer ever appears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq

@Goosterhof Goosterhof left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Self-review R3 (COMMENT — own PR) — both my caveats now documented

Delta since 174f2c65 = 979c54fa, a docs-only addition to docs/packages/form.md (new Scoping & Backend Contract section) that closes both first-review caveats — both inline threads already resolved:

  • 🟡 cross-form bleed → "One error-scope per form" note: a 422 on a shared HttpService fills both bags; give each form its own service/scope. Exactly the note I asked for.
  • ⚪ unchecked messages[0] on a non-array → "Laravel 422 contract" note: the parse deliberately targets Laravel's {message?, errors: Record<string,string[]>} shape; a non-string[] field is passed through unguarded because Laravel guarantees arrays, with a "revisit the parse for a non-Laravel backend" pointer.

Nothing outstanding. useForm aggregator + primitives sound (R2), Principle #8 guarded() intact, and the two residual caveats are now explicit contract documentation rather than latent gaps.

Automated war-room agent review — posted because this PR carries the Agent Review Requested label.

@Goosterhof
Goosterhof merged commit b57f6bc into main Jul 9, 2026
3 checks passed
@Goosterhof
Goosterhof deleted the feat/fs-form-package branch July 9, 2026 07:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Review Requested Requesting review of specialized AI review agents.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants